/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../../scripts/bundled-polymer.js';

import '../../../behaviors/fire-behavior/fire-behavior.js';
import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
import '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
import '../../core/gr-navigation/gr-navigation.js';
import '../../core/gr-reporting/gr-reporting.js';
import '../../shared/gr-button/gr-button.js';
import '../../shared/gr-dialog/gr-dialog.js';
import '../../shared/gr-dropdown/gr-dropdown.js';
import '../../shared/gr-icons/gr-icons.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import '../../shared/gr-overlay/gr-overlay.js';
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
import '../../../styles/shared-styles.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-change-actions_html.js';

const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
/**
 * @enum {string}
 */
const LabelStatus = {
  /**
   * This label provides what is necessary for submission.
   */
  OK: 'OK',
  /**
   * This label prevents the change from being submitted.
   */
  REJECT: 'REJECT',
  /**
   * The label may be set, but it's neither necessary for submission
   * nor does it block submission if set.
   */
  MAY: 'MAY',
  /**
   * The label is required for submission, but has not been satisfied.
   */
  NEED: 'NEED',
  /**
   * The label is required for submission, but is impossible to complete.
   * The likely cause is access has not been granted correctly by the
   * project owner or site administrator.
   */
  IMPOSSIBLE: 'IMPOSSIBLE',
  OPTIONAL: 'OPTIONAL',
};

const ChangeActions = {
  ABANDON: 'abandon',
  DELETE: '/',
  DELETE_EDIT: 'deleteEdit',
  EDIT: 'edit',
  FOLLOW_UP: 'followup',
  IGNORE: 'ignore',
  MOVE: 'move',
  PRIVATE: 'private',
  PRIVATE_DELETE: 'private.delete',
  PUBLISH_EDIT: 'publishEdit',
  REBASE_EDIT: 'rebaseEdit',
  RESTORE: 'restore',
  REVERT: 'revert',
  REVERT_SUBMISSION: 'revert_submission',
  REVIEWED: 'reviewed',
  STOP_EDIT: 'stopEdit',
  UNIGNORE: 'unignore',
  UNREVIEWED: 'unreviewed',
  WIP: 'wip',
};

const RevisionActions = {
  CHERRYPICK: 'cherrypick',
  REBASE: 'rebase',
  SUBMIT: 'submit',
  DOWNLOAD: 'download',
};

const ActionLoadingLabels = {
  abandon: 'Abandoning...',
  cherrypick: 'Cherry-picking...',
  delete: 'Deleting...',
  move: 'Moving..',
  rebase: 'Rebasing...',
  restore: 'Restoring...',
  revert: 'Reverting...',
  revert_submission: 'Reverting Submission...',
  submit: 'Submitting...',
};

const ActionType = {
  CHANGE: 'change',
  REVISION: 'revision',
};

const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';

const QUICK_APPROVE_ACTION = {
  __key: 'review',
  __type: 'change',
  enabled: true,
  key: 'review',
  label: 'Quick approve',
  method: 'POST',
};

const ActionPriority = {
  CHANGE: 2,
  DEFAULT: 0,
  PRIMARY: 3,
  REVIEW: -3,
  REVISION: 1,
};

const DOWNLOAD_ACTION = {
  enabled: true,
  label: 'Download patch',
  title: 'Open download dialog',
  __key: 'download',
  __primary: false,
  __type: 'revision',
};

const REBASE_EDIT = {
  enabled: true,
  label: 'Rebase edit',
  title: 'Rebase change edit',
  __key: 'rebaseEdit',
  __primary: false,
  __type: 'change',
  method: 'POST',
};

const PUBLISH_EDIT = {
  enabled: true,
  label: 'Publish edit',
  title: 'Publish change edit',
  __key: 'publishEdit',
  __primary: false,
  __type: 'change',
  method: 'POST',
};

const DELETE_EDIT = {
  enabled: true,
  label: 'Delete edit',
  title: 'Delete change edit',
  __key: 'deleteEdit',
  __primary: false,
  __type: 'change',
  method: 'DELETE',
};

const EDIT = {
  enabled: true,
  label: 'Edit',
  title: 'Edit this change',
  __key: 'edit',
  __primary: false,
  __type: 'change',
};

const STOP_EDIT = {
  enabled: true,
  label: 'Stop editing',
  title: 'Stop editing this change',
  __key: 'stopEdit',
  __primary: false,
  __type: 'change',
};

// Set of keys that have icons. As more icons are added to gr-icons.html, this
// set should be expanded.
const ACTIONS_WITH_ICONS = new Set([
  ChangeActions.ABANDON,
  ChangeActions.DELETE_EDIT,
  ChangeActions.EDIT,
  ChangeActions.PUBLISH_EDIT,
  ChangeActions.REBASE_EDIT,
  ChangeActions.RESTORE,
  ChangeActions.REVERT,
  ChangeActions.REVERT_SUBMISSION,
  ChangeActions.STOP_EDIT,
  QUICK_APPROVE_ACTION.key,
  RevisionActions.REBASE,
  RevisionActions.SUBMIT,
]);

const AWAIT_CHANGE_ATTEMPTS = 5;
const AWAIT_CHANGE_TIMEOUT_MS = 1000;

const REVERT_TYPES = {
  REVERT_SINGLE_CHANGE: 1,
  REVERT_SUBMISSION: 2,
};

/* Revert submission is skipped as the normal revert dialog will now show
the user a choice between reverting single change or an entire submission.
Hence, a second button is not needed.
*/
const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];

/**
 * @appliesMixin Gerrit.FireMixin
 * @appliesMixin Gerrit.PatchSetMixin
 * @appliesMixin Gerrit.RESTClientMixin
 * @extends Polymer.Element
 */
class GrChangeActions extends mixinBehaviors( [
  Gerrit.FireBehavior,
  Gerrit.PatchSetBehavior,
  Gerrit.RESTClientBehavior,
], GestureEventListeners(
    LegacyElementMixin(
        PolymerElement))) {
  static get template() { return htmlTemplate; }

  static get is() { return 'gr-change-actions'; }
  /**
   * Fired when the change should be reloaded.
   *
   * @event reload-change
   */

  /**
   * Fired when an action is tapped.
   *
   * @event custom-tap - naming pattern: <action key>-tap
   */

  /**
   * Fires to show an alert when a send is attempted on the non-latest patch.
   *
   * @event show-alert
   */

  /**
   * Fires when a change action fails.
   *
   * @event show-error
   */

  constructor() {
    super();
    this.ActionType = ActionType;
    this.ChangeActions = ChangeActions;
    this.RevisionActions = RevisionActions;
  }

  static get properties() {
    return {
    /**
     * @type {{
     *    _number: number,
     *    branch: string,
     *    id: string,
     *    project: string,
     *    subject: string,
     *  }}
     */
      change: Object,
      actions: {
        type: Object,
        value() { return {}; },
      },
      primaryActionKeys: {
        type: Array,
        value() {
          return [
            RevisionActions.SUBMIT,
          ];
        },
      },
      disableEdit: {
        type: Boolean,
        value: false,
      },
      _hasKnownChainState: {
        type: Boolean,
        value: false,
      },
      _hideQuickApproveAction: {
        type: Boolean,
        value: false,
      },
      changeNum: String,
      changeStatus: String,
      commitNum: String,
      hasParent: {
        type: Boolean,
        observer: '_computeChainState',
      },
      latestPatchNum: String,
      commitMessage: {
        type: String,
        value: '',
      },
      /** @type {?} */
      revisionActions: {
        type: Object,
        notify: true,
        value() { return {}; },
      },
      // If property binds directly to [[revisionActions.submit]] it is not
      // updated when revisionActions doesn't contain submit action.
      /** @type {?} */
      _revisionSubmitAction: {
        type: Object,
        computed: '_getSubmitAction(revisionActions)',
      },
      // If property binds directly to [[revisionActions.rebase]] it is not
      // updated when revisionActions doesn't contain rebase action.
      /** @type {?} */
      _revisionRebaseAction: {
        type: Object,
        computed: '_getRebaseAction(revisionActions)',
      },
      privateByDefault: String,

      _loading: {
        type: Boolean,
        value: true,
      },
      _actionLoadingMessage: {
        type: String,
        value: '',
      },
      _allActionValues: {
        type: Array,
        computed: '_computeAllActions(actions.*, revisionActions.*,' +
          'primaryActionKeys.*, _additionalActions.*, change, ' +
          '_actionPriorityOverrides.*)',
      },
      _topLevelActions: {
        type: Array,
        computed: '_computeTopLevelActions(_allActionValues.*, ' +
          '_hiddenActions.*, _overflowActions.*)',
        observer: '_filterPrimaryActions',
      },
      _topLevelPrimaryActions: Array,
      _topLevelSecondaryActions: Array,
      _menuActions: {
        type: Array,
        computed: '_computeMenuActions(_allActionValues.*, ' +
          '_hiddenActions.*, _overflowActions.*)',
      },
      _overflowActions: {
        type: Array,
        value() {
          const value = [
            {
              type: ActionType.CHANGE,
              key: ChangeActions.WIP,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.DELETE,
            },
            {
              type: ActionType.REVISION,
              key: RevisionActions.CHERRYPICK,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.MOVE,
            },
            {
              type: ActionType.REVISION,
              key: RevisionActions.DOWNLOAD,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.IGNORE,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.UNIGNORE,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.REVIEWED,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.UNREVIEWED,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.PRIVATE,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.PRIVATE_DELETE,
            },
            {
              type: ActionType.CHANGE,
              key: ChangeActions.FOLLOW_UP,
            },
          ];
          return value;
        },
      },
      _actionPriorityOverrides: {
        type: Array,
        value() { return []; },
      },
      _additionalActions: {
        type: Array,
        value() { return []; },
      },
      _hiddenActions: {
        type: Array,
        value() { return []; },
      },
      _disabledMenuActions: {
        type: Array,
        value() { return []; },
      },
      // editPatchsetLoaded == "does the current selected patch range have
      // 'edit' as one of either basePatchNum or patchNum".
      editPatchsetLoaded: {
        type: Boolean,
        value: false,
      },
      // editMode == "is edit mode enabled in the file list".
      editMode: {
        type: Boolean,
        value: false,
      },
      editBasedOnCurrentPatchSet: {
        type: Boolean,
        value: true,
      },
      _revertChanges: Array,
    };
  }

  static get observers() {
    return [
      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
      '_changeChanged(change)',
      '_editStatusChanged(editMode, editPatchsetLoaded, ' +
        'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
    ];
  }

  /** @override */
  created() {
    super.created();
    this.addEventListener('fullscreen-overlay-opened',
        () => this._handleHideBackgroundContent());
    this.addEventListener('fullscreen-overlay-closed',
        () => this._handleShowBackgroundContent());
  }

  /** @override */
  ready() {
    super.ready();
    this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
    this._handleLoadingComplete();
  }

  _getSubmitAction(revisionActions) {
    return this._getRevisionAction(revisionActions, 'submit', null);
  }

  _getRebaseAction(revisionActions) {
    return this._getRevisionAction(revisionActions, 'rebase', null);
  }

  _getRevisionAction(revisionActions, actionName, emptyActionValue) {
    if (!revisionActions) {
      return undefined;
    }
    if (revisionActions[actionName] === undefined) {
      // Return null to fire an event when reveisionActions was loaded
      // but doesn't contain actionName. undefined doesn't fire an event
      return emptyActionValue;
    }
    return revisionActions[actionName];
  }

  reload() {
    if (!this.changeNum || !this.latestPatchNum) {
      return Promise.resolve();
    }

    this._loading = true;
    return this._getRevisionActions()
        .then(revisionActions => {
          if (!revisionActions) { return; }

          this.revisionActions = revisionActions;
          this._sendShowRevisionActions({
            change: this.change,
            revisionActions,
          });
          this._handleLoadingComplete();
        })
        .catch(err => {
          this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
          this._loading = false;
          throw err;
        });
  }

  _handleLoadingComplete() {
    Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
  }

  _sendShowRevisionActions(detail) {
    this.$.jsAPI.handleEvent(
        this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
        detail
    );
  }

  _changeChanged() {
    this.reload();
  }

  addActionButton(type, label) {
    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
      throw Error(`Invalid action type: ${type}`);
    }
    const action = {
      enabled: true,
      label,
      __type: type,
      __key: ADDITIONAL_ACTION_KEY_PREFIX +
          Math.random().toString(36)
              .substr(2),
    };
    this.push('_additionalActions', action);
    return action.__key;
  }

  removeActionButton(key) {
    const idx = this._indexOfActionButtonWithKey(key);
    if (idx === -1) {
      return;
    }
    this.splice('_additionalActions', idx, 1);
  }

  setActionButtonProp(key, prop, value) {
    this.set([
      '_additionalActions',
      this._indexOfActionButtonWithKey(key),
      prop,
    ], value);
  }

  setActionOverflow(type, key, overflow) {
    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
      throw Error(`Invalid action type given: ${type}`);
    }
    const index = this._getActionOverflowIndex(type, key);
    const action = {
      type,
      key,
      overflow,
    };
    if (!overflow && index !== -1) {
      this.splice('_overflowActions', index, 1);
    } else if (overflow) {
      this.push('_overflowActions', action);
    }
  }

  setActionPriority(type, key, priority) {
    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
      throw Error(`Invalid action type given: ${type}`);
    }
    const index = this._actionPriorityOverrides
        .findIndex(action => action.type === type && action.key === key);
    const action = {
      type,
      key,
      priority,
    };
    if (index !== -1) {
      this.set('_actionPriorityOverrides', index, action);
    } else {
      this.push('_actionPriorityOverrides', action);
    }
  }

  setActionHidden(type, key, hidden) {
    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
      throw Error(`Invalid action type given: ${type}`);
    }

    const idx = this._hiddenActions.indexOf(key);
    if (hidden && idx === -1) {
      this.push('_hiddenActions', key);
    } else if (!hidden && idx !== -1) {
      this.splice('_hiddenActions', idx, 1);
    }
  }

  getActionDetails(action) {
    if (this.revisionActions[action]) {
      return this.revisionActions[action];
    } else if (this.actions[action]) {
      return this.actions[action];
    }
  }

  _indexOfActionButtonWithKey(key) {
    for (let i = 0; i < this._additionalActions.length; i++) {
      if (this._additionalActions[i].__key === key) {
        return i;
      }
    }
    return -1;
  }

  _getRevisionActions() {
    return this.$.restAPI.getChangeRevisionActions(this.changeNum,
        this.latestPatchNum);
  }

  _shouldHideActions(actions, loading) {
    return loading || !actions || !actions.base || !actions.base.length;
  }

  _keyCount(changeRecord) {
    return Object.keys((changeRecord && changeRecord.base) || {}).length;
  }

  _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
      additionalActionsChangeRecord) {
    // Polymer 2: check for undefined
    if ([
      actionsChangeRecord,
      revisionActionsChangeRecord,
      additionalActionsChangeRecord,
    ].some(arg => arg === undefined)) {
      return;
    }

    const additionalActions = (additionalActionsChangeRecord &&
        additionalActionsChangeRecord.base) || [];
    this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
        this._keyCount(revisionActionsChangeRecord) === 0 &&
            additionalActions.length === 0;
    this._actionLoadingMessage = '';
    this._disabledMenuActions = [];

    const revisionActions = revisionActionsChangeRecord.base || {};
    if (Object.keys(revisionActions).length !== 0) {
      if (!revisionActions.download) {
        this.set('revisionActions.download', DOWNLOAD_ACTION);
      }
    }
  }

  /**
   * @param {string=} actionName
   */
  _deleteAndNotify(actionName) {
    if (this.actions && this.actions[actionName]) {
      delete this.actions[actionName];
      // We assign a fake value of 'false' to support Polymer 2
      // see https://github.com/Polymer/polymer/issues/2631
      this.notifyPath('actions.' + actionName, false);
    }
  }

  _editStatusChanged(editMode, editPatchsetLoaded,
      editBasedOnCurrentPatchSet, disableEdit) {
    // Polymer 2: check for undefined
    if ([
      editMode,
      editBasedOnCurrentPatchSet,
      disableEdit,
    ].some(arg => arg === undefined)) {
      return;
    }

    if (disableEdit) {
      this._deleteAndNotify('publishEdit');
      this._deleteAndNotify('rebaseEdit');
      this._deleteAndNotify('deleteEdit');
      this._deleteAndNotify('stopEdit');
      this._deleteAndNotify('edit');
      return;
    }
    if (this.actions && editPatchsetLoaded) {
      // Only show actions that mutate an edit if an actual edit patch set
      // is loaded.
      if (this.changeIsOpen(this.change)) {
        if (editBasedOnCurrentPatchSet) {
          if (!this.actions.publishEdit) {
            this.set('actions.publishEdit', PUBLISH_EDIT);
          }
          this._deleteAndNotify('rebaseEdit');
        } else {
          if (!this.actions.rebaseEdit) {
            this.set('actions.rebaseEdit', REBASE_EDIT);
          }
          this._deleteAndNotify('publishEdit');
        }
      }
      if (!this.actions.deleteEdit) {
        this.set('actions.deleteEdit', DELETE_EDIT);
      }
    } else {
      this._deleteAndNotify('publishEdit');
      this._deleteAndNotify('rebaseEdit');
      this._deleteAndNotify('deleteEdit');
    }

    if (this.actions && this.changeIsOpen(this.change)) {
      // Only show edit button if there is no edit patchset loaded and the
      // file list is not in edit mode.
      if (editPatchsetLoaded || editMode) {
        this._deleteAndNotify('edit');
      } else {
        if (!this.actions.edit) { this.set('actions.edit', EDIT); }
      }
      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
      // is loaded.
      if (editMode && !editPatchsetLoaded) {
        if (!this.actions.stopEdit) {
          this.set('actions.stopEdit', STOP_EDIT);
        }
      } else {
        this._deleteAndNotify('stopEdit');
      }
    } else {
      // Remove edit button.
      this._deleteAndNotify('edit');
    }
  }

  _getValuesFor(obj) {
    return Object.keys(obj).map(key => obj[key]);
  }

  _getLabelStatus(label) {
    if (label.approved) {
      return LabelStatus.OK;
    } else if (label.rejected) {
      return LabelStatus.REJECT;
    } else if (label.optional) {
      return LabelStatus.OPTIONAL;
    } else {
      return LabelStatus.NEED;
    }
  }

  /**
   * Get highest score for last missing permitted label for current change.
   * Returns null if no labels permitted or more than one label missing.
   *
   * @return {{label: string, score: string}|null}
   */
  _getTopMissingApproval() {
    if (!this.change ||
        !this.change.labels ||
        !this.change.permitted_labels) {
      return null;
    }
    let result;
    for (const label in this.change.labels) {
      if (!(label in this.change.permitted_labels)) {
        continue;
      }
      if (this.change.permitted_labels[label].length === 0) {
        continue;
      }
      const status = this._getLabelStatus(this.change.labels[label]);
      if (status === LabelStatus.NEED) {
        if (result) {
          // More than one label is missing, so it's unclear which to quick
          // approve, return null;
          return null;
        }
        result = label;
      } else if (status === LabelStatus.REJECT ||
          status === LabelStatus.IMPOSSIBLE) {
        return null;
      }
    }
    if (result) {
      const score = this.change.permitted_labels[result].slice(-1)[0];
      const maxScore =
          Object.keys(this.change.labels[result].values).slice(-1)[0];
      if (score === maxScore) {
        // Allow quick approve only for maximal score.
        return {
          label: result,
          score,
        };
      }
    }
    return null;
  }

  hideQuickApproveAction() {
    this._topLevelSecondaryActions =
      this._topLevelSecondaryActions
          .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
    this._hideQuickApproveAction = true;
  }

  _getQuickApproveAction() {
    if (this._hideQuickApproveAction) {
      return null;
    }
    const approval = this._getTopMissingApproval();
    if (!approval) {
      return null;
    }
    const action = Object.assign({}, QUICK_APPROVE_ACTION);
    action.label = approval.label + approval.score;
    const review = {
      drafts: 'PUBLISH_ALL_REVISIONS',
      labels: {},
    };
    review.labels[approval.label] = approval.score;
    action.payload = review;
    return action;
  }

  _getActionValues(actionsChangeRecord, primariesChangeRecord,
      additionalActionsChangeRecord, type) {
    if (!actionsChangeRecord || !primariesChangeRecord) { return []; }

    const actions = actionsChangeRecord.base || {};
    const primaryActionKeys = primariesChangeRecord.base || [];
    const result = [];
    const values = this._getValuesFor(
        type === ActionType.CHANGE ? ChangeActions : RevisionActions);
    const pluginActions = [];
    Object.keys(actions).forEach(a => {
      actions[a].__key = a;
      actions[a].__type = type;
      actions[a].__primary = primaryActionKeys.includes(a);
      // Plugin actions always contain ~ in the key.
      if (a.indexOf('~') !== -1) {
        this._populateActionUrl(actions[a]);
        pluginActions.push(actions[a]);
        // Add server-side provided plugin actions to overflow menu.
        this._overflowActions.push({
          type,
          key: a,
        });
        return;
      } else if (!values.includes(a)) {
        return;
      }
      actions[a].label = this._getActionLabel(actions[a]);

      // Triggers a re-render by ensuring object inequality.
      result.push(Object.assign({}, actions[a]));
    });

    let additionalActions = (additionalActionsChangeRecord &&
    additionalActionsChangeRecord.base) || [];
    additionalActions = additionalActions
        .filter(a => a.__type === type)
        .map(a => {
          a.__primary = primaryActionKeys.includes(a.__key);
          // Triggers a re-render by ensuring object inequality.
          return Object.assign({}, a);
        });
    return result.concat(additionalActions).concat(pluginActions);
  }

  _populateActionUrl(action) {
    const patchNum =
          action.__type === ActionType.REVISION ? this.latestPatchNum : null;
    this.$.restAPI.getChangeActionURL(
        this.changeNum, patchNum, '/' + action.__key)
        .then(url => action.__url = url);
  }

  /**
   * Given a change action, return a display label that uses the appropriate
   * casing or includes explanatory details.
   */
  _getActionLabel(action) {
    if (action.label === 'Delete') {
      // This label is common within change and revision actions. Make it more
      // explicit to the user.
      return 'Delete change';
    } else if (action.label === 'WIP') {
      return 'Mark as work in progress';
    }
    // Otherwise, just map the name to sentence case.
    return this._toSentenceCase(action.label);
  }

  /**
   * Capitalize the first letter and lowecase all others.
   *
   * @param {string} s
   * @return {string}
   */
  _toSentenceCase(s) {
    if (!s.length) { return ''; }
    return s[0].toUpperCase() + s.slice(1).toLowerCase();
  }

  _computeLoadingLabel(action) {
    return ActionLoadingLabels[action] || 'Working...';
  }

  _canSubmitChange() {
    return this.$.jsAPI.canSubmitChange(this.change,
        this._getRevision(this.change, this.latestPatchNum));
  }

  _getRevision(change, patchNum) {
    for (const rev of Object.values(change.revisions)) {
      if (this.patchNumEquals(rev._number, patchNum)) {
        return rev;
      }
    }
    return null;
  }

  showRevertDialog() {
    // The search is still broken if there is a " in the topic.
    const query = `submissionid: "${this.change.submission_id}"`;
    /* A chromium plugin expects that the modifyRevertMsg hook will only
    be called after the revert button is pressed, hence we populate the
    revert dialog after revert button is pressed. */
    this.$.restAPI.getChanges('', query)
        .then(changes => {
          this.$.confirmRevertDialog.populate(this.change,
              this.commitMessage, changes);
          this._showActionDialog(this.$.confirmRevertDialog);
        });
  }

  showRevertSubmissionDialog() {
    const query = 'submissionid:' + this.change.submission_id;
    this.$.restAPI.getChanges('', query)
        .then(changes => {
          this.$.confirmRevertSubmissionDialog.
              _populateRevertSubmissionMessage(
                  this.commitMessage, this.change, changes);
          this._showActionDialog(this.$.confirmRevertSubmissionDialog);
        });
  }

  _handleActionTap(e) {
    e.preventDefault();
    let el = dom(e).localTarget;
    while (el.tagName.toLowerCase() !== 'gr-button') {
      if (!el.parentElement) { return; }
      el = el.parentElement;
    }

    const key = el.getAttribute('data-action-key');
    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
        key.indexOf('~') !== -1) {
      this.fire(`${key}-tap`, {node: el});
      return;
    }
    const type = el.getAttribute('data-action-type');
    this._handleAction(type, key);
  }

  _handleOveflowItemTap(e) {
    e.preventDefault();
    const el = dom(e).localTarget;
    const key = e.detail.action.__key;
    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
        key.indexOf('~') !== -1) {
      this.fire(`${key}-tap`, {node: el});
      return;
    }
    this._handleAction(e.detail.action.__type, e.detail.action.__key);
  }

  _handleAction(type, key) {
    this.$.reporting.reportInteraction(`${type}-${key}`);
    switch (type) {
      case ActionType.REVISION:
        this._handleRevisionAction(key);
        break;
      case ActionType.CHANGE:
        this._handleChangeAction(key);
        break;
      default:
        this._fireAction(this._prependSlash(key), this.actions[key], false);
    }
  }

  _handleChangeAction(key) {
    let action;
    switch (key) {
      case ChangeActions.REVERT:
        this.showRevertDialog();
        break;
      case ChangeActions.REVERT_SUBMISSION:
        this.showRevertSubmissionDialog();
        break;
      case ChangeActions.ABANDON:
        this._showActionDialog(this.$.confirmAbandonDialog);
        break;
      case QUICK_APPROVE_ACTION.key:
        action = this._allActionValues.find(o => o.key === key);
        this._fireAction(
            this._prependSlash(key), action, true, action.payload);
        break;
      case ChangeActions.EDIT:
        this._handleEditTap();
        break;
      case ChangeActions.STOP_EDIT:
        this._handleStopEditTap();
        break;
      case ChangeActions.DELETE:
        this._handleDeleteTap();
        break;
      case ChangeActions.DELETE_EDIT:
        this._handleDeleteEditTap();
        break;
      case ChangeActions.FOLLOW_UP:
        this._handleFollowUpTap();
        break;
      case ChangeActions.WIP:
        this._handleWipTap();
        break;
      case ChangeActions.MOVE:
        this._handleMoveTap();
        break;
      case ChangeActions.PUBLISH_EDIT:
        this._handlePublishEditTap();
        break;
      case ChangeActions.REBASE_EDIT:
        this._handleRebaseEditTap();
        break;
      default:
        this._fireAction(this._prependSlash(key), this.actions[key], false);
    }
  }

  _handleRevisionAction(key) {
    switch (key) {
      case RevisionActions.REBASE:
        this._showActionDialog(this.$.confirmRebase);
        this.$.confirmRebase.fetchRecentChanges();
        break;
      case RevisionActions.CHERRYPICK:
        this._handleCherrypickTap();
        break;
      case RevisionActions.DOWNLOAD:
        this._handleDownloadTap();
        break;
      case RevisionActions.SUBMIT:
        if (!this._canSubmitChange()) { return; }
        this._showActionDialog(this.$.confirmSubmitDialog);
        break;
      default:
        this._fireAction(this._prependSlash(key),
            this.revisionActions[key], true);
    }
  }

  _prependSlash(key) {
    return key === '/' ? key : `/${key}`;
  }

  /**
   * _hasKnownChainState set to true true if hasParent is defined (can be
   * either true or false). set to false otherwise.
   */
  _computeChainState(hasParent) {
    this._hasKnownChainState = true;
  }

  _calculateDisabled(action, hasKnownChainState) {
    if (action.__key === 'rebase') {
      // Rebase button is only disabled when change has no parent(s).
      return hasKnownChainState === false;
    }
    return !action.enabled;
  }

  _handleConfirmDialogCancel() {
    this._hideAllDialogs();
  }

  _hideAllDialogs() {
    const dialogEls =
        dom(this.root).querySelectorAll('.confirmDialog');
    for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
    this.$.overlay.close();
  }

  _handleRebaseConfirm(e) {
    const el = this.$.confirmRebase;
    const payload = {base: e.detail.base};
    this.$.overlay.close();
    el.hidden = true;
    this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
  }

  _handleCherrypickConfirm() {
    this._handleCherryPickRestApi(false);
  }

  _handleCherrypickConflictConfirm() {
    this._handleCherryPickRestApi(true);
  }

  _handleCherryPickRestApi(conflicts) {
    const el = this.$.confirmCherrypick;
    if (!el.branch) {
      this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
      return;
    }
    if (!el.message) {
      this.fire('show-alert', {message: ERR_COMMIT_EMPTY});
      return;
    }
    this.$.overlay.close();
    el.hidden = true;
    this._fireAction(
        '/cherrypick',
        this.revisionActions.cherrypick,
        true,
        {
          destination: el.branch,
          base: el.baseCommit ? el.baseCommit : null,
          message: el.message,
          allow_conflicts: conflicts,
        }
    );
  }

  _handleMoveConfirm() {
    const el = this.$.confirmMove;
    if (!el.branch) {
      this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
      return;
    }
    this.$.overlay.close();
    el.hidden = true;
    this._fireAction(
        '/move',
        this.actions.move,
        false,
        {
          destination_branch: el.branch,
          message: el.message,
        }
    );
  }

  _handleRevertDialogConfirm(e) {
    const revertType = e.detail.revertType;
    const message = e.detail.message;
    const el = this.$.confirmRevertDialog;
    this.$.overlay.close();
    el.hidden = true;
    switch (revertType) {
      case REVERT_TYPES.REVERT_SINGLE_CHANGE:
        this._fireAction('/revert', this.actions.revert, false,
            {message});
        break;
      case REVERT_TYPES.REVERT_SUBMISSION:
        this._fireAction('/revert_submission', this.actions.revert_submission,
            false, {message});
        break;
      default:
        console.error('invalid revert type');
    }
  }

  _handleRevertSubmissionDialogConfirm() {
    const el = this.$.confirmRevertSubmissionDialog;
    this.$.overlay.close();
    el.hidden = true;
    this._fireAction('/revert_submission', this.actions.revert_submission,
        false, {message: el.message});
  }

  _handleAbandonDialogConfirm() {
    const el = this.$.confirmAbandonDialog;
    this.$.overlay.close();
    el.hidden = true;
    this._fireAction('/abandon', this.actions.abandon, false,
        {message: el.message});
  }

  _handleCreateFollowUpChange() {
    this.$.createFollowUpChange.handleCreateChange();
    this._handleCloseCreateFollowUpChange();
  }

  _handleCloseCreateFollowUpChange() {
    this.$.overlay.close();
  }

  _handleDeleteConfirm() {
    this._fireAction('/', this.actions[ChangeActions.DELETE], false);
  }

  _handleDeleteEditConfirm() {
    this._hideAllDialogs();

    this._fireAction('/edit', this.actions.deleteEdit, false);
  }

  _handleSubmitConfirm() {
    if (!this._canSubmitChange()) { return; }
    this._hideAllDialogs();
    this._fireAction('/submit', this.revisionActions.submit, true);
  }

  _getActionOverflowIndex(type, key) {
    return this._overflowActions
        .findIndex(action => action.type === type && action.key === key);
  }

  _setLoadingOnButtonWithKey(type, key) {
    this._actionLoadingMessage = this._computeLoadingLabel(key);
    let buttonKey = key;
    // TODO(dhruvsri): clean this up later
    // If key is revert-submission, then button key should be 'revert'
    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
      // Revert submission button no longer exists
      buttonKey = ChangeActions.REVERT;
    }

    // If the action appears in the overflow menu.
    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
      this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
        buttonKey);
      return function() {
        this._actionLoadingMessage = '';
        this._disabledMenuActions = [];
      }.bind(this);
    }

    // Otherwise it's a top-level action.
    const buttonEl = this.shadowRoot
        .querySelector(`[data-action-key="${buttonKey}"]`);
    buttonEl.setAttribute('loading', true);
    buttonEl.disabled = true;
    return function() {
      this._actionLoadingMessage = '';
      buttonEl.removeAttribute('loading');
      buttonEl.disabled = false;
    }.bind(this);
  }

  /**
   * @param {string} endpoint
   * @param {!Object|undefined} action
   * @param {boolean} revAction
   * @param {!Object|string=} opt_payload
   */
  _fireAction(endpoint, action, revAction, opt_payload) {
    const cleanupFn =
        this._setLoadingOnButtonWithKey(action.__type, action.__key);

    this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
        action).then(this._handleResponse.bind(this, action));
  }

  _showActionDialog(dialog) {
    this._hideAllDialogs();

    dialog.hidden = false;
    this.$.overlay.open().then(() => {
      if (dialog.resetFocus) {
        dialog.resetFocus();
      }
    });
  }

  // TODO(rmistry): Redo this after
  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
  _setLabelValuesOnRevert(newChangeId) {
    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
    if (!labels) { return Promise.resolve(); }
    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
  }

  _handleResponse(action, response) {
    if (!response) { return; }
    return this.$.restAPI.getResponseObject(response).then(obj => {
      switch (action.__key) {
        case ChangeActions.REVERT:
          this._waitForChangeReachable(obj._number)
              .then(() => this._setLabelValuesOnRevert(obj._number))
              .then(() => {
                Gerrit.Nav.navigateToChange(obj);
              });
          break;
        case RevisionActions.CHERRYPICK:
          this._waitForChangeReachable(obj._number).then(() => {
            Gerrit.Nav.navigateToChange(obj);
          });
          break;
        case ChangeActions.DELETE:
          if (action.__type === ActionType.CHANGE) {
            Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getUrlForRoot());
          }
          break;
        case ChangeActions.WIP:
        case ChangeActions.DELETE_EDIT:
        case ChangeActions.PUBLISH_EDIT:
        case ChangeActions.REBASE_EDIT:
          Gerrit.Nav.navigateToChange(this.change);
          break;
        case ChangeActions.REVERT_SUBMISSION:
          if (!obj.revert_changes || !obj.revert_changes.length) return;
          /* If there is only 1 change then gerrit will automatically
             redirect to that change */
          Gerrit.Nav.navigateToSearchQuery('topic: ' +
              obj.revert_changes[0].topic);
          break;
        default:
          this.dispatchEvent(new CustomEvent('reload-change',
              {detail: {action: action.__key}, bubbles: false}));
          break;
      }
    });
  }

  _handleShowRevertSubmissionChangesConfirm() {
    this._hideAllDialogs();
  }

  _handleResponseError(action, response, body) {
    if (action && action.__key === RevisionActions.CHERRYPICK) {
      if (response && response.status === 409 &&
          body && !body.allow_conflicts) {
        return this._showActionDialog(
            this.$.confirmCherrypickConflict);
      }
    }
    return response.text().then(errText => {
      this.fire('show-error',
          {message: `Could not perform action: ${errText}`});
      if (!errText.startsWith('Change is already up to date')) {
        throw Error(errText);
      }
    });
  }

  /**
   * @param {string} method
   * @param {string|!Object|undefined} payload
   * @param {string} actionEndpoint
   * @param {boolean} revisionAction
   * @param {?Function} cleanupFn
   * @param {!Object|undefined} action
   */
  _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
    const handleError = response => {
      cleanupFn.call(this);
      this._handleResponseError(action, response, payload);
    };

    return this.fetchChangeUpdates(this.change, this.$.restAPI)
        .then(result => {
          if (!result.isLatest) {
            this.fire('show-alert', {
              message: 'Cannot set label: a newer patch has been ' +
                  'uploaded to this change.',
              action: 'Reload',
              callback: () => {
                // Load the current change without any patch range.
                Gerrit.Nav.navigateToChange(this.change);
              },
            });

            // Because this is not a network error, call the cleanup function
            // but not the error handler.
            cleanupFn();

            return Promise.resolve();
          }
          const patchNum = revisionAction ? this.latestPatchNum : null;
          return this.$.restAPI.executeChangeAction(this.changeNum, method,
              actionEndpoint, patchNum, payload, handleError)
              .then(response => {
                cleanupFn.call(this);
                return response;
              });
        });
  }

  _handleAbandonTap() {
    this._showActionDialog(this.$.confirmAbandonDialog);
  }

  _handleCherrypickTap() {
    this.$.confirmCherrypick.branch = '';
    this._showActionDialog(this.$.confirmCherrypick);
  }

  _handleMoveTap() {
    this.$.confirmMove.branch = '';
    this.$.confirmMove.message = '';
    this._showActionDialog(this.$.confirmMove);
  }

  _handleDownloadTap() {
    this.fire('download-tap', null, {bubbles: false});
  }

  _handleDeleteTap() {
    this._showActionDialog(this.$.confirmDeleteDialog);
  }

  _handleDeleteEditTap() {
    this._showActionDialog(this.$.confirmDeleteEditDialog);
  }

  _handleFollowUpTap() {
    this._showActionDialog(this.$.createFollowUpDialog);
  }

  _handleWipTap() {
    this._fireAction('/wip', this.actions.wip, false);
  }

  _handlePublishEditTap() {
    this._fireAction('/edit:publish', this.actions.publishEdit, false);
  }

  _handleRebaseEditTap() {
    this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
  }

  _handleHideBackgroundContent() {
    this.$.mainContent.classList.add('overlayOpen');
  }

  _handleShowBackgroundContent() {
    this.$.mainContent.classList.remove('overlayOpen');
  }

  /**
   * Merge sources of change actions into a single ordered array of action
   * values.
   *
   * @param {!Array} changeActionsRecord
   * @param {!Array} revisionActionsRecord
   * @param {!Array} primariesRecord
   * @param {!Array} additionalActionsRecord
   * @param {!Object} change The change object.
   * @return {!Array}
   */
  _computeAllActions(changeActionsRecord, revisionActionsRecord,
      primariesRecord, additionalActionsRecord, change) {
    // Polymer 2: check for undefined
    if ([
      changeActionsRecord,
      revisionActionsRecord,
      primariesRecord,
      additionalActionsRecord,
      change,
    ].some(arg => arg === undefined)) {
      return [];
    }

    const revisionActionValues = this._getActionValues(revisionActionsRecord,
        primariesRecord, additionalActionsRecord, ActionType.REVISION);
    const changeActionValues = this._getActionValues(changeActionsRecord,
        primariesRecord, additionalActionsRecord, ActionType.CHANGE);
    const quickApprove = this._getQuickApproveAction();
    if (quickApprove) {
      changeActionValues.unshift(quickApprove);
    }

    return revisionActionValues
        .concat(changeActionValues)
        .sort(this._actionComparator.bind(this))
        .map(action => {
          if (ACTIONS_WITH_ICONS.has(action.__key)) {
            action.icon = action.__key;
          }
          return action;
        })
        .filter(action => !this._shouldSkipAction(action));
  }

  _getActionPriority(action) {
    if (action.__type && action.__key) {
      const overrideAction = this._actionPriorityOverrides
          .find(i => i.type === action.__type && i.key === action.__key);

      if (overrideAction !== undefined) {
        return overrideAction.priority;
      }
    }
    if (action.__key === 'review') {
      return ActionPriority.REVIEW;
    } else if (action.__primary) {
      return ActionPriority.PRIMARY;
    } else if (action.__type === ActionType.CHANGE) {
      return ActionPriority.CHANGE;
    } else if (action.__type === ActionType.REVISION) {
      return ActionPriority.REVISION;
    }
    return ActionPriority.DEFAULT;
  }

  /**
   * Sort comparator to define the order of change actions.
   */
  _actionComparator(actionA, actionB) {
    const priorityDelta = this._getActionPriority(actionA) -
        this._getActionPriority(actionB);
    // Sort by the button label if same priority.
    if (priorityDelta === 0) {
      return actionA.label > actionB.label ? 1 : -1;
    } else {
      return priorityDelta;
    }
  }

  _shouldSkipAction(action) {
    return SKIP_ACTION_KEYS.includes(action.__key);
  }

  _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
    const hiddenActions = hiddenActionsRecord.base || [];
    return actionRecord.base.filter(a => {
      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
      return !(overflow || hiddenActions.includes(a.__key));
    });
  }

  _filterPrimaryActions(_topLevelActions) {
    this._topLevelPrimaryActions = _topLevelActions.filter(action =>
      action.__primary);
    this._topLevelSecondaryActions = _topLevelActions.filter(action =>
      !action.__primary);
  }

  _computeMenuActions(actionRecord, hiddenActionsRecord) {
    const hiddenActions = hiddenActionsRecord.base || [];
    return actionRecord.base.filter(a => {
      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
      return overflow && !hiddenActions.includes(a.__key);
    }).map(action => {
      let key = action.__key;
      if (key === '/') { key = 'delete'; }
      return {
        name: action.label,
        id: `${key}-${action.__type}`,
        action,
        tooltip: action.title,
      };
    });
  }

  _computeRebaseOnCurrent(revisionRebaseAction) {
    if (revisionRebaseAction) {
      return !!revisionRebaseAction.enabled;
    }
    return null;
  }

  /**
   * Occasionally, a change created by a change action is not yet knwon to the
   * API for a brief time. Wait for the given change number to be recognized.
   *
   * Returns a promise that resolves with true if a request is recognized, or
   * false if the change was never recognized after all attempts.
   *
   * @param  {number} changeNum
   * @return {Promise<boolean>}
   */
  _waitForChangeReachable(changeNum) {
    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
    return new Promise(resolve => {
      const check = () => {
        attempsRemaining--;
        // Pass a no-op error handler to avoid the "not found" error toast.
        this.$.restAPI.getChange(changeNum, () => {}).then(response => {
          // If the response is 404, the response will be undefined.
          if (response) {
            resolve(true);
            return;
          }

          if (attempsRemaining) {
            this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
          } else {
            resolve(false);
          }
        });
      };
      check();
    });
  }

  _handleEditTap() {
    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
  }

  _handleStopEditTap() {
    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
  }

  _computeHasTooltip(title) {
    return !!title;
  }

  _computeHasIcon(action) {
    return action.icon ? '' : 'hidden';
  }
}

customElements.define(GrChangeActions.is, GrChangeActions);
